Popular Searches
Popular Course Categories
Popular Courses

Managing loading, error, and empty states

Managing loading, error, and empty states

Flutter APIs & Networking

Managing Loading, Error, and Empty States in Flutter

When a Flutter application communicates with an API, the UI must handle different situations such as waiting for data, successfully receiving data, receiving an empty result, or encountering an error. Managing these states properly makes an application more reliable, user-friendly, and easier to maintain.

Flutter's FutureBuilder can be used to build UI according to the state of an asynchronous operation, including loading and error states. For larger applications, state-management approaches such as ChangeNotifier can also represent loading, success, and error states.

1. What Are UI States?

A state represents the current condition of data or an operation in the application. When working with APIs, a screen commonly needs to represent four primary states:

  • Loading: Data is currently being requested.
  • Success: Data has been received successfully.
  • Empty: The request succeeded, but there is no data to display.
  • Error: The request failed or the received data could not be processed.

2. Why State Management Is Important

Without proper state handling, users may see a blank screen, an endlessly spinning loader, confusing error messages, or an outdated result. A well-designed screen clearly communicates what is happening.

StateMeaningTypical UI
LoadingAPI request is in progressCircularProgressIndicator
SuccessValid data is availableList, cards, details, etc.
EmptyRequest succeeded but contains no usable recordsNo data message
ErrorRequest or processing failedError message and Retry button

3. Loading State

The loading state is displayed while Flutter waits for an asynchronous operation to complete. A common loading widget is CircularProgressIndicator.

const Center(
  child: CircularProgressIndicator(),
)

4. Why Show a Loading Indicator?

  • It tells the user that the application is working.
  • It prevents the screen from appearing frozen.
  • It provides feedback during slow network operations.
  • It improves the perceived responsiveness of the application.
  • It clearly separates the waiting state from the result state.

5. Simple Loading Example

bool isLoading = true;

Widget buildContent() {
  if (isLoading) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }

  return const Text('Data loaded');
}

6. Loading State with FutureBuilder

FutureBuilder is a built-in Flutter widget that makes it convenient to build UI from asynchronous data. The Future is supplied to the widget, and the builder decides what to display according to the Future's state.

FutureBuilder(
  future: fetchData(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }

    return const Text('Data loaded');
  },
)

7. Understanding ConnectionState

The connectionState property can help determine the current stage of a Future.

ConnectionStateMeaningCommon UI
noneNo asynchronous operation is connectedInitial or empty content
waitingFuture is waiting for completionLoading indicator
activeAsynchronous operation is activeProgress-related UI
doneFuture has completedSuccess or error UI

8. Error State

An error state is displayed when an API request, network operation, JSON parsing operation, authentication process, or another asynchronous task fails.

Common API-related errors include:

  • No internet connection
  • Invalid API URL
  • Server error
  • Authentication failure
  • Request timeout
  • Invalid response format
  • JSON parsing error
  • Requested resource not found

9. Checking for Errors with FutureBuilder

snapshot.hasError can be used to determine whether the Future completed with an error.

FutureBuilder(
  future: fetchData(),
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return const Text(
        'Something went wrong.',
      );
    }

    if (snapshot.hasData) {
      return Text(snapshot.data!);
    }

    return const CircularProgressIndicator();
  },
)

10. User-Friendly Error Messages

Technical error information is useful for developers, but users should normally receive a simple and understandable message.

if (snapshot.hasError) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Icon(
          Icons.error_outline,
          size: 50,
        ),
        const SizedBox(height: 12),
        const Text(
          'Unable to load data.',
          style: TextStyle(fontSize: 18),
        ),
        const SizedBox(height: 10),
        ElevatedButton(
          onPressed: retry,
          child: const Text('Try Again'),
        ),
      ],
    ),
  );
}

11. Retry Button

A Retry button allows the user to repeat a failed API operation without leaving the screen.

void retry() {
  setState(() {
    dataFuture = fetchData();
  });
}

The button can then call the retry method:

ElevatedButton(
  onPressed: retry,
  child: const Text('Try Again'),
)

12. Empty State

An empty state occurs when an API request completes successfully but there is no data to display. An empty result is different from an error.

For example, a product-search API might successfully return an empty list because there are no products matching the search.

if (users.isEmpty) {
  return const Center(
    child: Text('No users found.'),
  );
}

13. Why Empty State Is Important

  • It tells the user that the request succeeded.
  • It prevents a blank screen from appearing.
  • It distinguishes "no data" from "request failed".
  • It can provide useful next actions.
  • It improves the overall user experience.

14. Empty State with an Icon

Center(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      const Icon(
        Icons.inbox_outlined,
        size: 60,
      ),
      const SizedBox(height: 12),
      const Text(
        'No items available.',
        style: TextStyle(fontSize: 18),
      ),
    ],
  ),
)

15. Empty State with an Action

Center(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      const Icon(
        Icons.search_off,
        size: 60,
      ),
      const SizedBox(height: 12),
      const Text('No results found.'),
      const SizedBox(height: 12),
      ElevatedButton(
        onPressed: clearSearch,
        child: const Text('Clear Search'),
      ),
    ],
  ),
)

16. Loading, Success, Empty, and Error Flow

Start
  |
  v
Loading
  |
  v
API Request
  |
  +-------------------+
  |                   |
  v                   v
Success              Error
  |                   |
  v                   v
Has Data?          Show Error
  |
  +----------+
  |          |
  v          v
Yes          No
 |           |
 v           v
Display    Empty State
Data

17. Complete FutureBuilder State Handling

FutureBuilder<List<String>>(
  future: itemsFuture,
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }

    if (snapshot.hasError) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Unable to load items.'),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: retry,
              child: const Text('Retry'),
            ),
          ],
        ),
      );
    }

    if (!snapshot.hasData || snapshot.data!.isEmpty) {
      return const Center(
        child: Text('No items available.'),
      );
    }

    final items = snapshot.data!;

    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text(items[index]),
        );
      },
    );
  },
)

18. Why Check Error Before Empty Data?

Error and empty states represent different situations. An error means the operation failed, while an empty state means the operation completed but there is no content to show.

if (snapshot.hasError) {
  return const Text('Failed to load data');
}

if (!snapshot.hasData || snapshot.data!.isEmpty) {
  return const Text('No data available');
}

19. API Service Example

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<List<String>> fetchUsers() async {
  final response = await http.get(
    Uri.parse(
      'https://jsonplaceholder.typicode.com/users',
    ),
  );

  if (response.statusCode == 200) {
    final data = jsonDecode(response.body) as List;

    return data
        .map(
          (user) => user['name'] as String,
        )
        .toList();
  }

  throw Exception(
    'Failed to load users',
  );
}

20. Complete StatefulWidget Example

import 'package:flutter/material.dart';

class UsersPage extends StatefulWidget {
  const UsersPage({super.key});

  @override
  State<UsersPage> createState() => _UsersPageState();
}

class _UsersPageState extends State<UsersPage> {
  late Future<List<String>> usersFuture;

  @override
  void initState() {
    super.initState();
    usersFuture = fetchUsers();
  }

  void retry() {
    setState(() {
      usersFuture = fetchUsers();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Users'),
      ),
      body: FutureBuilder<List<String>>(
        future: usersFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(
                    Icons.error_outline,
                    size: 50,
                  ),
                  const SizedBox(height: 12),
                  const Text(
                    'Unable to load users.',
                  ),
                  const SizedBox(height: 12),
                  ElevatedButton(
                    onPressed: retry,
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }

          final users = snapshot.data ?? [];

          if (users.isEmpty) {
            return const Center(
              child: Text(
                'No users found.',
              ),
            );
          }

          return ListView.builder(
            itemCount: users.length,
            itemBuilder: (context, index) {
              return ListTile(
                leading: CircleAvatar(
                  child: Text(
                    users[index][0],
                  ),
                ),
                title: Text(users[index]),
              );
            },
          );
        },
      ),
    );
  }
}

21. Avoid Calling APIs Inside build()

API calls should generally not be created directly inside the build() method. Flutter can call build() multiple times, which can cause the request to execute repeatedly.

A common approach is to initialize the Future in initState() and store it in a state variable.

late Future<List<String>> usersFuture;

@override
void initState() {
  super.initState();
  usersFuture = fetchUsers();
}

22. Handling API Errors with try-catch

Future<List<String>> fetchUsers() async {
  try {
    final response = await http.get(
      Uri.parse(
        'https://example.com/api/users',
      ),
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body) as List;

      return data
          .map(
            (item) => item['name'] as String,
          )
          .toList();
    }

    throw Exception(
      'Server returned ${response.statusCode}',
    );
  } catch (e) {
    throw Exception(
      'Unable to fetch users',
    );
  }
}

23. Handling Different HTTP Errors

if (response.statusCode == 200) {
  return parseUsers(response.body);
}

if (response.statusCode == 401) {
  throw Exception(
    'Authentication required',
  );
}

if (response.statusCode == 404) {
  throw Exception(
    'Resource not found',
  );
}

if (response.statusCode >= 500) {
  throw Exception(
    'Server error',
  );
}

throw Exception(
  'Unexpected response',
);

24. Creating Reusable Loading Widget

When many screens use the same loading UI, creating a reusable widget can reduce duplicated code.

class LoadingWidget extends StatelessWidget {
  const LoadingWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

Usage:

if (isLoading) {
  return const LoadingWidget();
}

25. Creating a Reusable Error Widget

class ErrorWidgetView extends StatelessWidget {
  final String message;
  final VoidCallback onRetry;

  const ErrorWidgetView({
    super.key,
    required this.message,
    required this.onRetry,
  });

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(
            Icons.error_outline,
            size: 50,
          ),
          const SizedBox(height: 10),
          Text(message),
          const SizedBox(height: 10),
          ElevatedButton(
            onPressed: onRetry,
            child: const Text('Retry'),
          ),
        ],
      ),
    );
  }
}

26. Creating a Reusable Empty Widget

class EmptyStateWidget extends StatelessWidget {
  final String message;

  const EmptyStateWidget({
    super.key,
    required this.message,
  });

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(
            Icons.inbox_outlined,
            size: 60,
          ),
          const SizedBox(height: 12),
          Text(message),
        ],
      ),
    );
  }
}

27. Using the Reusable Widgets

if (isLoading) {
  return const LoadingWidget();
}

if (hasError) {
  return ErrorWidgetView(
    message: 'Unable to load products.',
    onRetry: loadProducts,
  );
}

if (products.isEmpty) {
  return const EmptyStateWidget(
    message: 'No products available.',
  );
}

return ProductList(
  products: products,
);

28. Managing State with Boolean Variables

A small application may use boolean variables to represent state.

bool isLoading = false;
bool hasError = false;
List<String> items = [];

However, using multiple booleans can create confusing combinations. For example, both isLoading and hasError could accidentally become true. For more complex applications, a single explicit state representation can be easier to maintain.

29. Using an Enum for API State

enum ApiState {
  loading,
  success,
  empty,
  error,
}

Now the screen can store one primary state:

ApiState state = ApiState.loading;

30. Building UI from an Enum State

Widget buildContent() {
  switch (state) {
    case ApiState.loading:
      return const LoadingWidget();

    case ApiState.success:
      return const Text('Data loaded');

    case ApiState.empty:
      return const EmptyStateWidget(
        message: 'No data found',
      );

    case ApiState.error:
      return ErrorWidgetView(
        message: 'Unable to load data',
        onRetry: loadData,
      );
  }
}

31. State Management with ChangeNotifier

For larger applications, loading and error states can be managed in a ViewModel or another state-management layer. ChangeNotifier can expose properties such as loading status, data, and errors, followed by notifyListeners() so listening widgets can rebuild.

class UserViewModel extends ChangeNotifier {
  bool isLoading = false;
  List<String> users = [];
  Exception? error;

  Future<void> loadUsers() async {
    isLoading = true;
    error = null;
    notifyListeners();

    try {
      users = await fetchUsers();
    } catch (e) {
      error = Exception(
        'Unable to load users',
      );
    } finally {
      isLoading = false;
      notifyListeners();
    }
  }
}

32. Displaying ViewModel States

if (viewModel.isLoading) {
  return const LoadingWidget();
}

if (viewModel.error != null) {
  return ErrorWidgetView(
    message: 'Unable to load users.',
    onRetry: viewModel.loadUsers,
  );
}

if (viewModel.users.isEmpty) {
  return const EmptyStateWidget(
    message: 'No users found.',
  );
}

return UserList(
  users: viewModel.users,
);

33. Loading During Refresh

Initial loading and refreshing existing data can sometimes require different UI behavior. During the initial request, a full-screen loader may be appropriate. During a refresh, keeping existing content visible while showing a smaller progress indicator can provide a smoother experience.

Column(
  children: [
    if (isRefreshing)
      const LinearProgressIndicator(),
    Expanded(
      child: UserList(
        users: users,
      ),
    ),
  ],
)

34. Pull-to-Refresh

RefreshIndicator can be used to allow users to pull a list downward to refresh its data.

RefreshIndicator(
  onRefresh: loadUsers,
  child: ListView.builder(
    itemCount: users.length,
    itemBuilder: (context, index) {
      return ListTile(
        title: Text(users[index]),
      );
    },
  ),
)

35. Empty Search Results

Search screens often require a special empty state. The user may have successfully searched the server, but no matching records may exist.

if (searchResults.isEmpty) {
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Icon(
          Icons.search_off,
          size: 60,
        ),
        const SizedBox(height: 12),
        Text(
          'No results for "$query"',
        ),
        const SizedBox(height: 12),
        ElevatedButton(
          onPressed: clearSearch,
          child: const Text('Clear Search'),
        ),
      ],
    ),
  );
}

36. Empty State vs Error State

Empty StateError State
Request completed successfullyRequest failed
No matching data existsData could not be retrieved
Usually no technical problemUsually requires error handling
May provide search/filter actionsUsually provides Retry
Example: "No products found"Example: "Unable to load products"

37. Avoiding Infinite Loading

A common problem is displaying a loading indicator indefinitely. The asynchronous operation should complete with either data or an error. When a Future is expected to produce data, make sure the asynchronous operation reaches a completed state with a meaningful result or error rather than leaving the UI waiting indefinitely.

Future<String> fetchData() async {
  final response = await http.get(
    Uri.parse('https://example.com/data'),
  );

  if (response.statusCode == 200) {
    return response.body;
  }

  throw Exception(
    'Failed to load data',
  );
}

38. Using finally for Loading State

The finally block is useful for ensuring that the loading state is reset whether the operation succeeds or fails.

Future<void> loadData() async {
  setState(() {
    isLoading = true;
  });

  try {
    data = await fetchData();
  } catch (e) {
    errorMessage = 'Unable to load data';
  } finally {
    if (mounted) {
      setState(() {
        isLoading = false;
      });
    }
  }
}

39. Complete State Management Example

enum ApiState {
  loading,
  success,
  empty,
  error,
}

class ProductsPage extends StatefulWidget {
  const ProductsPage({super.key});

  @override
  State<ProductsPage> createState() => _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
  ApiState state = ApiState.loading;
  List<String> products = [];

  @override
  void initState() {
    super.initState();
    loadProducts();
  }

  Future<void> loadProducts() async {
    setState(() {
      state = ApiState.loading;
    });

    try {
      final result = await fetchProducts();

      if (!mounted) {
        return;
      }

      if (result.isEmpty) {
        setState(() {
          products = [];
          state = ApiState.empty;
        });
      } else {
        setState(() {
          products = result;
          state = ApiState.success;
        });
      }
    } catch (e) {
      if (!mounted) {
        return;
      }

      setState(() {
        state = ApiState.error;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Products'),
      ),
      body: switch (state) {
        ApiState.loading => const LoadingWidget(),
        ApiState.error => ErrorWidgetView(
          message: 'Unable to load products.',
          onRetry: loadProducts,
        ),
        ApiState.empty => const EmptyStateWidget(
          message: 'No products available.',
        ),
        ApiState.success => ListView.builder(
          itemCount: products.length,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text(products[index]),
            );
          },
        ),
      },
    );
  }
}

40. State Management Best Practices

  1. Always provide feedback while asynchronous data is loading.
  2. Always handle API failures.
  3. Differentiate an empty response from an error.
  4. Provide a Retry action for recoverable errors.
  5. Use simple and user-friendly error messages.
  6. Keep API logic separate from presentation logic when possible.
  7. Avoid starting API requests repeatedly inside build().
  8. Use FutureBuilder for simple Future-based screens.
  9. Use a structured state-management approach for larger applications.
  10. Clear stale errors when starting a new request.
  11. Reset loading state after both successful and failed requests.
  12. Disable duplicate actions while an operation is running.
  13. Provide meaningful empty-state actions where appropriate.
  14. Keep existing data visible during refresh when that provides a better user experience.

41. Common Mistakes

  • Showing a blank screen while data loads.
  • Showing a loading spinner forever.
  • Treating an empty list as an API error.
  • Showing raw technical exceptions to users.
  • Forgetting to provide a Retry button.
  • Calling APIs repeatedly inside build().
  • Not resetting the loading flag after an exception.
  • Using multiple unrelated boolean variables that can produce contradictory states.
  • Ignoring invalid or unexpected API responses.
  • Not handling empty search results.

42. Practical API State Flow

Application Starts
        |
        v
   Loading State
        |
        v
   Send API Request
        |
        v
   Receive Response
        |
   +----+----+
   |         |
Success     Error
   |         |
   v         v
Check Data  Error UI
   |
  +--+--+
  |     |
Data  Empty
  |     |
  v     v
UI    Empty UI

43. Interview Questions

  1. What is a loading state in Flutter?
  2. Why should an application display a loading indicator?
  3. What is an error state?
  4. What is an empty state?
  5. What is the difference between an empty state and an error state?
  6. How does FutureBuilder handle asynchronous data?
  7. What is snapshot.hasError?
  8. What is snapshot.hasData?
  9. What is ConnectionState.waiting?
  10. Why should API calls generally not be made inside build()?
  11. How can you implement a Retry button?
  12. How can you handle an empty API response?
  13. Why is finally useful for loading states?
  14. How can ChangeNotifier be used to manage loading and error states?
  15. Why can an enum be useful for representing API states?

44. Practice Exercise

Create a Flutter application that displays a list of products from an API.

  1. Create a Flutter project.
  2. Add the http package.
  3. Create a model for the product.
  4. Create an API service.
  5. Make a GET request.
  6. Show a loading indicator while the request is running.
  7. Display products after a successful response.
  8. Display an empty-state widget when the API returns an empty list.
  9. Display an error widget when the request fails.
  10. Add a Retry button.
  11. Add pull-to-refresh functionality.
  12. Add a search field.
  13. Display a separate empty state when a search returns no results.
  14. Prevent duplicate requests while loading.

45. Quick Revision Table

ConceptPurpose
Loading StateShows that an asynchronous operation is in progress
Success StateDisplays successfully received data
Empty StateShows that there is no data to display
Error StateShows that an operation failed
FutureBuilderBuilds UI from a Future's state
snapshot.hasDataChecks whether non-null data is available
snapshot.hasErrorChecks whether the Future completed with an error
ConnectionState.waitingIndicates that the Future is still waiting
RetryStarts the operation again after failure
ChangeNotifierCan notify listening widgets when state changes
notifyListeners()Signals listeners to rebuild using updated state

46. Learning Outcome

After completing this topic, you should be able to:

  • Understand loading, success, empty, and error states.
  • Build asynchronous UI using FutureBuilder.
  • Display meaningful loading indicators.
  • Handle API and network errors.
  • Create user-friendly error and empty-state widgets.
  • Implement Retry functionality.
  • Prevent unnecessary API calls inside build().
  • Use enums to represent application states.
  • Use ChangeNotifier for structured state handling.
  • Implement pull-to-refresh functionality.
  • Design reusable state widgets for larger applications.

47. Summary

Managing loading, error, and empty states is an essential part of building reliable Flutter applications. A good API-driven screen should clearly represent what is happening at every stage of the request.

The basic flow is Loading → Success/Empty/Error. FutureBuilder is useful for simple Future-based asynchronous operations, while a structured state-management approach such as ChangeNotifier can be useful when an application becomes more complex.

By separating API logic from UI logic, providing meaningful feedback, handling failures, and designing clear empty states, Flutter applications become easier to understand and maintain.


48. Useful Resources

49. JustAcademy Flutter Training

Learn Flutter development through structured practical training covering Dart programming, Flutter UI development, API integration, Firebase, state management, and application development.

Explore JustAcademy Flutter Training Course

Register for Flutter Course Demo

Previous Next
whatsapp